跳到主要内容

JavaScript 的协程

生成器 generator

生成器是一个带星号的函数,而且是可以暂停和恢复执行的。

  • 在生成器内部执行一段代码,如果遇到 yield 关键字,那么 JavaScript 引擎返回关键字后面的内容给外部,并暂停该函数的执行。
  • 外部函数可以通过 next() 方法恢复执行。

JavaScript 中协程是怎样的

协程是一种比线程更加轻量级的存在 ,可以把协程看成跑在线程上的任务。一个线程可以存在多个协程,但是在线程上只能执行有一个写程。

  • 当前执行的是 a 协程,要启动 b 协程。a 协程就需要主动把控制权交给b协程,这就体现了 a 协程暂停,b 协程恢复执行。(类似线程的切换)
  • 如果 a 协程启动 b 协程,我们把 a 协程称作 b 协程的父协程

正如一个进程可以拥有多个线程一样,一个线程可以拥有多个协程。重要的是协程不被操作系统内核管,而是有程序所控制(也就是用户态执行)。这样带来的好处就是性能能够得到很大的提升。不会像线程那么消耗资源。

function* idMaker() {
let index = 0;
while (true) yield index++;
}

let gen = idMaker(); // "Generator { }"

console.log(gen.next().value); // 0
console.log(gen.next().value); // 1
console.log(gen.next().value); // 2
// ...

没错,就是一个生成器,生成器本身是一个函数,也就是说在 JavaScript 中协程是由一个生成器函数实现的。

JavaScript 协程如何切换

协程本身是个函数,协程之间的切换本质是函数执行权的转移。

生成器函数的 yield 关键字有可以交出函数的执行权,挂起自身,然后 JavaScript 引擎去执行这个函数后面的语句,在上面这个例子中,第 8 行调用 gen.next() 开始执行生成器函数的内容,第一次 while 循环里 yield 交出了执行权,JavaScript 引擎转而执行第 9 行,再次调用 gen.next(),这时候 JavaScript 接着上次挂起的地方执行,不会重新执行 let index = 0 语句,然后执行函数内的语句,对于这个例子,使用 yieldnext() 方法就能不断的交出和恢复函数的执行权,怎么样,是不是有点感觉了?

站在一个线程的角度看,线程的切换就是这样不断让 CPU 暂停和继续对自己执行。

上面这个例子是把生成器函数的执行权交给普通函数(你也可以把非协程看做是一个协程整体),也可以在一个协程中调用另一个协程,实现协程之间的切换,比如这个例子:

function* anotherGenerator(i) {
yield i + 1;
yield i + 2;
yield i + 3;
}

function* generator(i) {
yield i;
yield* anotherGenerator(i); // 第九行 移交执行权
yield i + 10;
}

var gen = generator(10);

console.log(gen.next().value); // 10
console.log(gen.next().value); // 11
console.log(gen.next().value); // 12
console.log(gen.next().value); // 13
console.log(gen.next().value); // 20

第 9 行使用 yield* 将执行权交给另一个生成器函数,接下来要等到这个生成器函数 anotherGenerator() 执行完毕执行权才会回到 generator 函数。这和普通函数表现一致,都是后进先出,如果感兴趣可以去看看 JavaScript 事件循环机制(Event Loop),本篇就不再多说了。

协程如何实现异步和非阻塞

异步

搞清楚什么是同步,什么是异步,问题才能很好的讨论下去。先说同步:

res = function fetchFun() {
// 请求资源
res = ....
return res;
};

console.log(res);

计算机按照程序顺序执行代码,比如这几行代码一定第三行先给 res 赋值,然后才是打印 res,而非同步(即异步)可以先执行后面的 console.log(res),然后再给 res 赋值。

为什么需要这么做呢,当一个请求需要耗费大量的时间,程序执行一直停留在这一行,就会引发阻塞,最容易受影响的是 eventListener,事件监听没了,在请求数据的时候点击事件都是无效的。

所以 实现异步的关键就是把会阻塞线程函数的执行权交出去,让这个函数等待恢复执行,等待的时间内请求(或者其他异步任务)也该执行完了,这时候再来继续执行这个函数。

通过前面对协程的运行方式的讲解我们很容易就能想到用协程来解决这个问题,利用 yield 挂起这个阻塞线程函数,然后继续执行后面的语句,等这个函数不再阻塞了,再回到这个函数继续执行。

那么问题来了,应该什么时候继续执行这个挂起的函数呢?你可能想到大概估计一下阻塞时间,设定时间再回来执行,这个方案。。。有点牵强。

Promise

这时候 Promise 就派上用场了,Promise 本质是一个状态机,用于表示一个异步操作的最终完成 (或失败),及其结果值。它有三个状态:

  • pending:初始状态,既不是成功,也不是失败状态。
  • fulfilled:意味着操作成功完成。
  • rejected:意味着操作失败。

最终 Promise 会有两种状态,一种成功,一种失败,当 pending 变化的时候,Promise 对象会根据最终的状态调用不同的处理函数。

根据 Promise 的特点,他是一个状态机,在 yield 之后可以用 Promise 来表示异步任务是否执行完毕(是否是 pending 状态),并且还能够自动判别异步任务成功与否(fulfilled 还是 rejected)并执行处理函数。

如此看来用协程 + Promise 可以完美实现异步。

好的,让我们来根据上面的理论实现一下吧:

// 模拟阻塞事件
function resolveAfter2Seconds(val) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(val);
}, 2000);
});
}

// 实现生成器
function* coroutineFunc(val) {
yield resolveAfter2Seconds(val);
}

let doIt = coroutineFunc("OK");
let value = doIt.next().value;

// value 是 Promise 对象
value.then((res) => {
console.log(res);
});

// 模拟后面被阻塞的语句
for (let i = 0; i < 10; i++) {
console.log(i);
}

这段代码的输出顺序是 0=>1=>2=>...=>9,两秒之后输出 'OK',从输出顺序来看我们已经实现了异步。其执行过程和之前说的一样,挂起会阻塞运行的函数,继续执行后面的语句,等待 Promise 改变状态并自动执行处理函数。

使用 Generator、Promise 组合和直接使用 Promise 的区别

实际上下面这段代码运行顺序的结果和上面一模一样:

function resolveAfter2Seconds(val) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(val);
}, 2000);
});
}

resolveAfter2Seconds("OK").then((res) => {
console.log(res);
});

为什么要使用上面那种复杂的写法呢?

为了简化问题,便于理解,我已经简化了代码,在前一个例子中,生成器函数内,yield 行后面完全可以写更多的代码,这些代码一定是在异步获取到数据之后才执行的。

如果直接使用 Promise 需要把这些代码放在 then 代码块里边才能保证在异步获取到值之后执行,那么当有多个异步事件的时候问题就来了——可怕的嵌套!(例如需要请求某个网络资源后,再请求另一个网络资源)

Async、Await

ECMAscript2017 中提供了更高级的协程控制语法,其被看做是对 Generator 和 Promise 组合的封装,使异步函数看起来更像同步函数,减轻开发者的痛苦。

上面的例子改写:

function resolveAfter2Seconds(x) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(x);
}, 2000);
});
}

async function f1() {
var x = await resolveAfter2Seconds(10);
console.log(x); // 10
}

可以看出 Async、Await 实现了 Generator 的自动迭代,不需要手动使用 next() 方法来继续执行。

Reference

Node.js 真的有协程吗? - 圆珠笔的回答 - 知乎 JavaScript中的协程 - 丛来花间翁的文章 - 知乎 Node.js 真的有协程吗? - 陈厚来的回答 - 知乎